Carry dynamic-extent declarations from bytecode into BIR - #1821
Conversation
Compiles an escaping closure with the native compiler 30 times and calls each result. Before the companion Cleavir fix the closure was stack allocated on roughly half the compiles -- the reader iteration order that decides it comes from an EQ hash table -- so one compile is not a reliable probe, while 30 makes a miss effectively impossible. This needs the DETERMINE-CLOSURE-EXTENT fix in Cleavir. repos.sexp tracks Cleavir's main unpinned, so the test goes green once that lands upstream.
Clasp does not use Cleavir's CST-to-AST front end -- CLEAVIR-CST-TO-AST, CLEAVIR-AST and CLEAVIR-AST-TO-BIR are not present at runtime at all -- so the declaration reaches BIR by way of the bytecode instead. It is already carried there: CMPLTV encodes a dynamic-extent bit into the debug-var flags byte, and START-ANNOTATION already reads BYTECODE-DEBUG-VAR/DECLS to recover the declared type. It was simply not passed on. Read CL:DYNAMIC-EXTENT out of that same list and set it on the BIR:LETI binding the variable. Cleavir's closure extent analysis then treats it as permission to attempt a stack allocation, which it still has to prove. Measured on a closure passed to a capturing FLET called from three sites, 100000 calls: 544 bytes per call before, 504 after, results unchanged. The 40 bytes are the closure itself moving to the stack. It only fires when the local function captures something; a non-capturing FLET is a constant function, so the callee is a constant reference with no BIR function behind it to walk into. Also adds two regression tests: one pins the safety property, that a declaration which is wrong forfeits the optimisation rather than handing back a closure whose frame is gone, and one pins that a correct declaration does not change the value computed.
|
Blocked on two Cleavir PRs, now open:
Without #35 this does not merely fail a test: |
|
Heads-up on this draft: I measured it on x86-64 Linux (LLVM 18, Probe: a closure declared
Identical in every cell — the closure is heap-allocated either way. I checked the obvious way this measurement could be wrong: that the probe never reached the native compiler, which would make both readings 40.0 trivially. It does reach it — the function starts as The reason looks structural: on Cleavir (defclass leti (writevar) () ...)with no slots and no Worth being aware that the three new tests pass vacuously in this configuration: they assert an escaping dynamic-extent closure remains valid and that a wrong declaration is safe, both of which are trivially true when nothing is ever stack-allocated. A build that ignores the declaration entirely passes them perfectly. They will only be meaningful once the Cleavir side lands, and it would be worth adding a positive test that asserts a non-escaping declared closure actually stops allocating — otherwise there is no test that can distinguish "working" from "inert". Full regression suite on this branch is clean: 1966 successes, exit 0 (1963 baseline + the 3 new tests). |
A
(declare (dynamic-extent g))on a variable bound to a closure was parsed and then discarded, so it never reached the compiler. This wires it through and lets the closure extent analysis act on it.Where the declaration was lost
Clasp does not use Cleavir's CST-to-AST front end —
CLEAVIR-CST-TO-AST,CLEAVIR-ASTandCLEAVIR-AST-TO-BIRare not present in the image at all. Clasp goes source → bytecode → BIR, so the declaration has to travel through the bytecode, and it already does:cmp/cmpltv.lisppacks a dynamic-extent bit into the debug-var flags byte, andcompile-bytecode.lisp'sstart-annotationalready reads(core:bytecode-debug-var/decls bdv)to recover the declared type. The bit simply was not passed on.This reads
cl:dynamic-extentout of that same list and sets it on thebir:letithat binds the variable, which is where BIR's own docstring says a dynamic-extent declaration belongs — the declaration constrains the extent of the value bound rather than of the variable.Permission, not proof
The declaration only permits the compiler to try. The escape analysis still has to succeed independently, so a declaration that is wrong costs the optimisation rather than memory safety. Two deliberately-wrong cases were checked: a local function that returns the closure, and one that stores it in a global. Both keep the closure heap-allocated and both still work when called afterwards.
Effect
A closure passed to a capturing
fletcalled from three sites, 100000 calls: 544 → 504 bytes per call, results unchanged. The 40 bytes are the closure moving to the stack.It only fires when the local function captures something. A non-capturing
fletcompiles to a constant function, so the call's callee is a constant reference with no BIR function behind it to analyse.Dependency
Needs both of these in Cleavir:
Only mark a closure dynamic-extent once every reader has been checked— a soundness fix, independent of this feature.determine-closure-extentmarked an enclose:dynamicas soon as one reader turned out to be a DX call; a later escaping reader bailed out without undoing it. Because Cleavir sets are EQ hash tables the iteration order varies, so compiling an escaping closure 300 times stack-allocated it 154 times, and calling one givesEXT:BUS-ERROR.Let a dynamic-extent declaration reach the closure extent analysis— adds thebir:leti:dynamic-extentslot this branch writes to, plus the escape analysis that consumes it.Without the second one,
build:insert 'bir:leti :dynamic-extent …is an initarg no slot accepts, so this does not merely fail a test — the tree does not build.repos.sexptracks Cleavirmainunpinned, so once both land upstream this branch picks them up with no change here.Tests
Two regression tests, neither of which needs the optimisation to fire in order to be meaningful:
The companion test for the Cleavir soundness fix compiles an escaping closure 30 times and calls each result, since a single compile is a coin flip.